Skip to content

Per-component / tag cardinality limits in client-side stats#11387

Open
dougqh wants to merge 257 commits into
masterfrom
dougqh/control-tag-cardinality
Open

Per-component / tag cardinality limits in client-side stats#11387
dougqh wants to merge 257 commits into
masterfrom
dougqh/control-tag-cardinality

Conversation

@dougqh

@dougqh dougqh commented May 15, 2026

Copy link
Copy Markdown
Contributor

What Does This Do

Implements per-component / per-tag cadinality limits to improve user experience under high load.

Motivation

Previously, there was a single global cap and per-component / tag caches that helped curtail allocation and bound live objects, but this approach had a couple problems.

While the aggregate table was sized capped, failure to insert into the aggregate table would lead to silent data loss. There weren't any obvious indications to the customer when metrics were lost.

And under extreme loads, the caches would degenerate to constantly missing and allocating which would result in long GC cycles.

The per-element limiting allows us to substitute a sentinel value to indicate what was dropped and why to the trace agent / backend. Additionally, this change includes logging and metrics to indicate to the user what is happening locally.

Additional Notes

The cardinality handlers introduced in this change serve dual roles. They both track cardinality and act as caches for UTF8 encodings.

By cardinality limiting first, constant allocation from string concatenation and UTF8 encoding is avoided. And given that a cache and cardinality limiter are basically both sets of recently used values, it seemed most efficient to combine them.

The one difference between the cardinality limiter and the cache is that the cardinality limiter is regularly fully reset -- which hurts the use of the limiter as a cache. To make up for that, the cardinality limiter also holds onto the values used in the previous cycle for reuse in the new cycle.

Claude's Summary

Stack: master → #11382#11478this PR. Bounds client-stats label cardinality, reworks peer-tag handling, renames the aggregator, and adds a design doc. The lazy-errorLatencies memory win that originally lived in this PR's downstream (#11389) was extracted ahead of #11387 into #11478 during a stack resequence, so the per-entry footprint reduction lands independently of the cardinality machinery.

  • Cardinality control: replaces the per-field DDCaches with PropertyCardinalityHandler / TagCardinalityHandler. Each has a per-field budget; once exhausted, the sentinel-substitution behavior is gated by the new trace.stats.cardinality.limits.enabled flag (default false). With the flag on, overflow values canonicalize to a blocked_by_tracer sentinel UTF8BytesString and collapse to one bucket. With the flag off (the default), the cache size is still capped at the same budget but over-cap values get freshly-allocated UTF8BytesStrings and flow to distinct buckets — so the wire format is identical to Update client-side stats to use light weight Hashtable #11382. Handlers reset every reporting cycle in either mode.
  • Canonicalize before hashing: AggregateTable.findOrInsert runs every label through its handler before computing the lookup hash, so cardinality-blocked values collapse into one bucket instead of fragmenting into N entries.
  • Peer tags reworked: producer captures peer-tag values only (parallel String[] to a PeerTagSchema.names array). tag:value interning happens on the aggregator thread via TagCardinalityHandler. The schema is synced once per trace via PeerTagSchema.currentSyncedTo(Set) with an identity-check fast path.
  • Rename: ConflatingMetricsAggregatorClientStatsAggregator.
  • Producer-side wins identified via JFR: use the cached span.kind byte ordinal through a new CoreSpan.getSpanKindString() (skips a tag-map lookup per metrics-eligible span); hoist schema.names out of capturePeerTagValues; avoid toString() allocation in isSynthetic.
  • Cleanups: fix TracerHealthMetrics.previousCounts size bug that would have silently dropped the new statsInboxFull counter; drop dead clearAggregates().
  • Docs: new docs/client_metrics_design.md covering the pipeline shape, the canonical-key trick, thread-safety contract, reporting cadence, failure modes, and benchmark numbers.

Benchmark

ClientStatsAggregatorDDSpanBenchmark — producer publish() latency

(64 client-kind DDSpans per op, real CoreTracer)

Stage µs/op
master baseline 6.428
stack tip before this PR 2.454
+ peer-tag schema hoist 2.410
+ cached span-kind ordinal 1.995

~3.2× over master end to end on the producer side.

Aggregator bench suite vs v1.62.0 + master + #11382

Re-measured 2026-05-27 with three benches in matrix form: full adversarial (all four label dimensions vary) and two cardinality-isolation companions (only resource varies; only peer.hostname varies). Same machine state, same JMH config (8 producer threads, 2×15s warmup + 5×15s, 1 fork, throughput mode). The HighCardinality* and Adversarial benches were backported onto the v1.62.0 tag using its ConflatingMetricsAggregator constructor and HealthMetrics.NO_OP (v1.62.0 predates the inbox split so per-iteration drop counters are not directly comparable).

v1.62.0 release master (post-#11381) #11382 #11387 limits OFF (default) #11387 limits ON
AdversarialMetricsBenchmark (ops/s) 444,290 ± 1,616,937 14,276,351 ± 1,091,138 32,556,300 ± 4,321,490 23,480,978 ± 2,221,623 8,068,173 ± 1,754,400
vs v1.62.0 1.00× 32.1× 73.3× 52.9× 18.2×
HighCardinalityResource (ops/s) 4,854,335 ± 1,214,233 8,168,005 ± 3,493,716 35,739,452 ± 2,556,684 28,866,978 ± 1,251,950 25,095,814 ± 1,934,690
vs v1.62.0 1.00× 1.68× 7.36× 5.94× 5.17×
HighCardinalityPeer (ops/s) 6,902,209 ± 368,641 10,110,142 ± 3,380,594 37,638,634 ± 6,673,337 29,635,631 ± 5,710,512 27,408,255 ± 1,722,131
vs v1.62.0 1.00× 1.46× 5.45× 4.29× 3.97×
Adversarial onStatsAggregateDropped n/a (HealthMetrics.NO_OP) 155,251,623 16,568,738 12,336,616 0
Resource onStatsAggregateDropped n/a 188,023,595 16,557,066 9,773,903 0
Peer onStatsAggregateDropped n/a 223,260,962 14,904,938 17,983,372 0

Customer headline: vs the shipping v1.62.0 release, this branch at the default flag setting (limits OFF) delivers ~50× throughput on adversarial cardinality and ~5–6× on single-axis cardinality. With the flag ON (sentinel-substitution active), ~18× / 4–5× plus zero onStatsAggregateDropped — i.e. the cardinality cap actually saves the bench from data loss. v1.62.0's Adversarial per-iteration progression shows the classic degradation curve (1.08M warmup → 277K → 199K) where the LRU cache thrashes catastrophically; this PR holds steady-state across iterations in either flag mode.

Reading the trade-off:

  1. onStatsAggregateDropped = 0 only with limits ON. That's the safety guarantee the feature pays for. Every other config drops 10–225 M aggregate updates under adversarial cardinality because over-cap values fragment into distinct buckets and saturate tracerMetricsMaxAggregates.

  2. Adversarial-bench limits-on cost is real. All four label dimensions exhaust their cap simultaneously, so every snapshot pays the full sentinel-substitution + blockedCounts++ + warnedCardinality bookkeeping on all four fields. Single-axis benches (HighCardinality*) show a much smaller limits-on penalty (~10%) because only one dimension is over-cap. Workloads with one runaway dimension and the rest bounded sit much closer to the limits-off throughput.

  3. Variance collapses dramatically with limits on. ±1.72 M / ±1.75 M / ±1.93 M on limits-on vs ±5–6.67 M without. Bounded cardinality means no eviction sweeps, stable table size, no per-cycle GC churn — predictable throughput. For workloads paged on p99 latency spikes during reporting cycles, this is often more valuable than peak throughput.

  4. Benches are adversarial. Designed to saturate every capacity bound at once; realistic workloads with smaller working sets see proportionally smaller throughput gaps between configs. The 19–66% limits-on penalty vs Update client-side stats to use light weight Hashtable #11382 is an upper bound, not a steady-state cost.

Architecture note on the limits-off cost. Limits-off matches #11382's wire format exactly, but still costs ~19% on HighCardinality* and ~28% on Adversarial. The gap comes from AggregateTable.findOrInsert canonicalizing every snapshot before lookup — required for the sentinel collapse in limits-on, but pure overhead in limits-off where the hash is content-stable across raw vs canonicalized forms. A two-path findOrInsert (hash-raw on limits-off, canonicalize-first on limits-on) would likely close most of the gap; deferred as a follow-up optimization if the default-off cost matters in practice.

Test plan

  • :dd-trace-core:test — metrics tests pass (existing + new AggregateTableTest cases for cardinality collapse)
  • JMH benchmark numbers reproduce locally
  • No behavior change to client-stats wire payload for traces within the cardinality budget

🤖 Generated with Claude Code

@dougqh dougqh added type: enhancement Enhancements and improvements tag: performance Performance related changes tag: no release notes Changes to exclude from release notes comp: metrics Metrics tag: ai generated Largely based on code generated by an AI or LLM labels May 15, 2026
Comment thread dd-trace-core/src/main/java/datadog/trace/common/metrics/PeerTagSchema.java Outdated
dougqh and others added 6 commits May 18, 2026 11:19
Two general-purpose utilities used by the client-side stats aggregator
work (PR #11382 and follow-ups), extracted into their own change so the
metrics-specific PRs can build on a smaller, reviewable foundation.

  - Hashtable: a generic open-addressed-ish bucket table abstraction
    keyed by a 64-bit hash, with a public abstract Entry type so client
    code can subclass it for higher-arity keys. The metrics aggregator
    uses it to back its AggregateTable.

  - LongHashingUtils: chained 64-bit hash combiners with primitive
    overloads (boolean, short, int, long, Object). Used in place of
    varargs combiners to avoid Object[] allocation and boxing on the
    hot path.

No callers within internal-api itself yet -- the metrics aggregator PR
will introduce the first usages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Standalone classes for swapping the consumer-side LRUCache<MetricKey,
AggregateMetric> with a multi-key Hashtable in the next commit. No call sites
use them yet.

- AggregateEntry extends Hashtable.Entry, holds the canonical MetricKey, the
  mutable AggregateMetric, and copies of the 13 raw SpanSnapshot fields for
  matches(). The 64-bit lookup hash is computed via chained
  LongHashingUtils.addToHash calls (no varargs, no boxing of short/boolean).
- AggregateTable wraps a Hashtable.Entry[] from Hashtable.Support.create.
  findOrInsert(SpanSnapshot) walks the bucket comparing raw fields, falling
  back to MetricKeys.fromSnapshot on a true miss. On cap overrun, it scans
  for an entry with hitCount==0 and unlinks it; if none, it returns null and
  the caller drops the data point.
- MetricKeys.fromSnapshot extracts the canonicalization logic (DDCache
  lookups + UTF8 encoding) from Aggregator.buildMetricKey, so the helper can
  be called from AggregateTable on miss.

This also commits Hashtable and LongHashingUtils (added earlier, previously
uncommitted) and lifts Hashtable.Entry / Hashtable.Support visibility so
client code outside datadog.trace.util can build higher-arity tables -- the
case the javadoc describes but the original visibility didn't actually
support. Specifically: Entry is now public abstract with a protected ctor;
keyHash, next(), and setNext() are public; Support's create / clear /
bucketIndex / bucketIterator / mutatingBucketIterator methods are public.

Tests: AggregateTableTest covers hit, miss, distinct-by-spanKind, peer-tag
identity (including null vs non-null), cap overrun with stale victim, cap
overrun with no victim (returns null), expungeStaleAggregates, forEach,
clear, and that the canonical MetricKey is built at insert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace LRUCache<MetricKey, AggregateMetric> with the AggregateTable added
in the prior commit. The hot path in Drainer.accept becomes:

  AggregateMetric aggregate = aggregates.findOrInsert(snapshot);
  if (aggregate != null) {
      aggregate.recordOneDuration(snapshot.tagAndDuration);
      dirty = true;
  } else {
      healthMetrics.onStatsAggregateDropped();
  }

On the steady-state hit path the lookup is a 64-bit hash compute + bucket
walk + matches(snapshot) -- no MetricKey allocation, no SERVICE_NAMES /
SPAN_KINDS / PEER_TAGS_CACHE lookups. The canonical MetricKey is now built
once per unique key at insert time, in MetricKeys.fromSnapshot.

Behavioral change in the cap-overrun path
-----------------------------------------

The old LRUCache evicted least-recently-used: at cap, a new insert would
push out the oldest entry regardless of whether it was live or stale.
AggregateTable instead scans for a hitCount==0 entry to recycle, and drops
the new key if none exists. Practical impact: in the common case where
the table holds a stable set of recurring keys, an unrelated burst of new
keys is dropped (and reported via onStatsAggregateDropped) rather than
evicting the established keys. The existing test that asserted "service0
evicted in favor of service10" is updated to assert the new semantics.
The other cap-related test ("should not report dropped aggregate when
evicted entry was already flushed") still passes unchanged: after report()
clears all entries to hitCount=0, the next wave of inserts recycles them.

Threading fix
-------------

ConflatingMetricsAggregator.disable() used to call aggregator.clearAggregates()
and inbox.clear() directly from the Sink's IO event thread, racing with the
aggregator thread mid-write. The race was tolerable for LinkedHashMap; it
is not for AggregateTable (chain corruption can NPE or loop). disable()
now offers a ClearSignal to the inbox so the aggregator thread itself
performs the table clear and the inbox.clear(). Adds one SignalItem
subclass + one branch in Drainer.accept; preserves the single-writer
invariant for AggregateTable end-to-end.

Removed: LRUCache import, AggregateExpiry inner class, the static
buildMetricKey / materializePeerTags / encodePeerTag helpers (now in
MetricKeys).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MetricKey existed for two reasons -- the prior LRUCache key role (now handled
by AggregateTable's Hashtable.Entry mechanics) and as the labels argument
to MetricWriter.add. The first is gone; the second is the only thing keeping
MetricKey alive. Fold its UTF8-encoded label fields onto AggregateEntry,
change MetricWriter.add to take AggregateEntry directly, and delete
MetricKey + MetricKeys.

What AggregateEntry now holds
-----------------------------

- 10 UTF8BytesString label fields (resource, service, operationName,
  serviceSource, type, spanKind, httpMethod, httpEndpoint, grpcStatusCode,
  and a List<UTF8BytesString> peerTags for serialization).
- 3 primitives (httpStatusCode, synthetic, traceRoot).
- AggregateMetric (the value being accumulated).
- The raw String[] peerTagPairs is retained alongside the encoded peerTags
  -- matches() compares it positionally against the snapshot's pairs; the
  encoded form is only consumed by the writer.

matches(SpanSnapshot) compares the entry's UTF8 forms to the snapshot's raw
String / CharSequence fields via content-equality (UTF8BytesString.toString()
returns the underlying String in O(1)). This closes a latent bug in the
prior raw-vs-raw matches(): if one snapshot delivered a tag value as String
and a later snapshot delivered the same content as UTF8BytesString, the old
Objects.equals would return false and the table would split into two
entries. Content-equality matching collapses them into one.

Consolidated caches
-------------------

The static UTF8 caches that used to live partly on MetricKey (RESOURCE_CACHE,
OPERATION_CACHE, SERVICE_SOURCE_CACHE, TYPE_CACHE, KIND_CACHE,
HTTP_METHOD_CACHE, HTTP_ENDPOINT_CACHE, GRPC_STATUS_CODE_CACHE, SERVICE_CACHE)
and partly on ConflatingMetricsAggregator (SERVICE_NAMES, SPAN_KINDS,
PEER_TAGS_CACHE) are all now on AggregateEntry. The split was duplicating
work -- SERVICE_NAMES and SERVICE_CACHE both cached service-name to
UTF8BytesString. One cache per field now.

API change: MetricWriter.add
----------------------------

Was: add(MetricKey key, AggregateMetric aggregate)
Now: add(AggregateEntry entry)

The aggregate lives on the entry. Single-arg.

SerializingMetricWriter reads the same UTF8 fields off AggregateEntry that it
previously read off MetricKey; the wire format is byte-identical.

Test impact
-----------

AggregateEntry.of(...) takes the same 13 positional args new MetricKey(...)
took, so test diffs are mostly mechanical:
  new MetricKey(args) -> AggregateEntry.of(args)
  writer.add(key, _)  -> writer.add(entry)

ValidatingSink in SerializingMetricWriterTest now iterates List<AggregateEntry>
directly. ConflatingMetricAggregatorTest's Spock matchers (~36 sites) rely
on AggregateEntry.equals comparing the 13 label fields (not the aggregate)
so the mock matches by labels regardless of the aggregate state at call time;
post-invocation closures verify aggregate state.

Benchmarks (2 forks x 5 iter x 15s)
-----------------------------------

The change is consumer-thread only; producer publish() is unchanged.

  SimpleSpan bench:   3.123 +- 0.025 us/op   (prior: 3.119 +- 0.018)
  DDSpan bench:       2.412 +- 0.022 us/op   (prior: 2.463 +- 0.041)

Both within noise -- the win is structural (one less class, one less
allocation per miss, one fewer cache layer) rather than benchmarked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@dougqh dougqh force-pushed the dougqh/optimize-metric-key branch from 050a998 to 3738c85 Compare May 18, 2026 19:20
dougqh and others added 8 commits May 18, 2026 15:24
Replaces the per-field DDCache layer inside AggregateEntry with the two new
cardinality handlers. Each per-field handler holds a small HashMap working
set; when its budget is exhausted, subsequent values collapse to a stable
"blocked_by_tracer" sentinel UTF8BytesString rather than growing without
bound. The handlers are reset on the aggregator thread at the end of each
report() cycle (10s default), so the cardinality budget refreshes per
reporting interval.

Caches replaced (limits preserved from the prior DDCache sizes):

  RESOURCE_HANDLER         32
  SERVICE_HANDLER          32
  OPERATION_HANDLER        64
  SERVICE_SOURCE_HANDLER   16
  TYPE_HANDLER              8
  SPAN_KIND_HANDLER        16
  HTTP_METHOD_HANDLER       8
  HTTP_ENDPOINT_HANDLER    32
  GRPC_STATUS_CODE_HANDLER 32
  PEER_TAG_HANDLERS        per-tag-name TagCardinalityHandler, each 512

Two production-only changes to the handlers as the user wrote them:

- Fixed import: datadog.collections.tagmap6lazy.TagMap doesn't exist;
  TagCardinalityHandler now imports datadog.trace.api.TagMap which has the
  Entry API the handler uses.
- Added TagCardinalityHandler.register(String) overload so AggregateEntry's
  peer-tag canonicalization doesn't have to allocate a TagMap.Entry per
  call -- the snapshot already carries peer-tag values as a flattened
  String[] {name, value, ...}.

AggregateEntry split into two construction paths:

- forSnapshot(snapshot, agg): the hot path; runs each field through the
  appropriate handler.
- of(...): test-only factory; bypasses the handlers and creates UTF8
  instances directly, so tests don't pollute static handler state. Content-
  equality on the resulting entry still matches the production-built one.

Thread-safety: handlers are HashMap-backed and not safe for concurrent
access. Both forSnapshot and resetCardinalityHandlers must be called from
the aggregator thread. After the prior commits that moved MetricKey
construction to the aggregator thread, this is the only thread that
canonicalizes; the test factory path runs on test threads but doesn't
touch the handlers.

Reset semantics: clearing the handler's working set drops the {value ->
UTF8BytesString} mapping but doesn't invalidate existing AggregateEntry
fields -- those keep their UTF8BytesString references alive on their own.
Subsequent snapshots with the same content still resolve to the existing
entries via content-equality matches(). New values after reset get freshly
allocated UTF8BytesStrings via the handler.

Known limitation (not fixed here): hashOf(SpanSnapshot) hashes from the
raw snapshot fields, not from the post-handler canonical form. So when
cardinality is exceeded, multiple distinct raw values that collapse to
the "blocked_by_tracer" sentinel still produce distinct hashes and land
in different AggregateEntry buckets -- the wire payload will carry
multiple rows that all label as blocked. This is the same behavior the
prior DDCache-based design would have had at capacity. Collapsing those
into a single sentinel entry would require canonicalizing before hashing
and is a follow-up.

Tests: new CardinalityHandlerTest covers PropertyCardinalityHandler and
TagCardinalityHandler in isolation (hit/miss, over-limit blocking, reset
behavior, sentinel stability). Existing ConflatingMetricAggregatorTest /
SerializingMetricWriterTest / AggregateTableTest all pass unchanged
because the test factory bypasses handlers.

Benchmarks (2 forks x 5 iter x 15s) -- producer side unchanged because
the handlers live on the consumer thread:

  SimpleSpan bench:   3.114 +- 0.045 us/op   (prior: 3.123 +- 0.018)
  DDSpan bench:       2.364 +- 0.113 us/op   (prior: 2.412 +- 0.022)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The prior commit ran every snapshot through the cardinality handlers but still
hashed the raw snapshot fields. When a field exceeded its cardinality budget
the handlers collapsed many distinct values to a single "blocked_by_tracer"
sentinel, but the raw hashes were still all different -- so the blocked entries
fragmented across the AggregateTable. This commit makes hash + match work off
the canonical (post-handler) UTF8BytesString fields, so blocked values land in
the same bucket and merge into one entry.

How the lookup path changes
---------------------------

A new package-private AggregateEntry.Canonical scratch buffer:

  - holds the 10 canonical UTF8BytesString refs, primitives, peerTags list,
    and the precomputed keyHash;
  - exposes populate(SpanSnapshot) which runs each field through the
    appropriate handler and computes the long hash from the canonical refs;
  - exposes matches(AggregateEntry) for content-equality lookup;
  - exposes toEntry(AggregateMetric) which copies its refs into a fresh
    AggregateEntry on miss.

AggregateTable holds one Canonical instance and reuses it per findOrInsert.
On a hit nothing is allocated -- the buffer's refs feed the bucket walk and
matches() directly. On a miss the refs are copied into the new entry and the
buffer is overwritten on the next call.

Hash function
-------------

hashOf now takes UTF8BytesString fields (plus primitives + peerTags list)
instead of raw CharSequence/String from the snapshot. UTF8BytesString.hashCode
returns the underlying String's hash, so:

  - content-equal entries built via AggregateEntry.of(...) (test factory,
    bypasses handlers) produce the same hash as entries built via
    Canonical.toEntry(...) (production, via handlers);
  - all values that collapsed to "blocked_by_tracer" share that sentinel
    instance and therefore that hashCode -- they land in the same bucket and
    merge into one entry.

Matches
-------

The SpanSnapshot-keyed matches() on AggregateEntry is gone. Lookup goes
through Canonical.matches(entry) which compares the buffer's UTF8 fields
against the entry's UTF8 fields via Objects.equals (content equality on
UTF8BytesString). This is needed because across handler resets the
UTF8BytesString instance referenced by an existing entry differs from the
freshly-issued instance for the same content -- content-equality lets the
existing entry survive resets.

The peerTagPairsRaw field on AggregateEntry was previously kept for matching
against snapshot.peerTagPairs (the flat String[]). Canonical.matches uses
List.equals on the encoded UTF8 peerTags directly, so peerTagPairsRaw is
dropped.

New test in AggregateTableTest -- cardinalityBlockedValuesCollapseIntoOneEntry
inserts 50 distinct services into a table whose SERVICE_HANDLER has a
cardinality limit of 32, and asserts the final size is 33 (the 32 in-budget
services plus a single collapsed "blocked_by_tracer" entry, not 50 separate
entries).

Benchmarks (2 forks x 5 iter x 15s) -- producer side unchanged:

  SimpleSpan bench:   3.117 +- 0.026 us/op   (prior: 3.114 +- 0.045)
  DDSpan bench:       2.344 +- 0.114 us/op   (prior: 2.364 +- 0.113)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…chema-indexed handlers

Replaces the producer's early {@code (name, value)}-pair encoding with a
schema-based design: peer-tag values are captured into a parallel String
array, and the consumer applies the matching {@link TagCardinalityHandler}
by index using a {@link PeerTagSchema}'s parallel name/handler arrays.

This removes the {@code Map<String, TagCardinalityHandler>} the prior commit
left in {@code AggregateEntry} -- handler lookup is now a single array
dereference instead of a hashmap probe.

PeerTagSchema
-------------

New package-private class that holds:

  - {@code String[] names}    -- peer-tag names in stable order
  - {@code TagCardinalityHandler[] handlers} -- parallel to names

Two schemas exist: a static singleton {@code INTERNAL} for the internal-kind
{@code base.service} case, and a {@code CURRENT} schema for the peer-
aggregation kinds (client/producer/consumer) that lazily refreshes when
{@code features.peerTags()} returns a different set of names.

Each {@link SpanSnapshot} captures the schema reference it was built against
so producer and consumer agree on the indexing even if {@code CURRENT}
changes between capture and consumption.

A fast-path identity check (cached last input Set instance) keeps the
{@code currentSyncedTo} call cheap: when the producer hands in the same
Set instance as last time -- the steady-state case --
{@code currentSyncedTo} returns immediately without iterating names. The
{@code matches()} loop only runs when the Set instance changes, which in
production is rare (only on remote-config reconfiguration).

Snapshot shape
--------------

{@code SpanSnapshot.peerTagPairs} (a flat {@code [name0, value0, name1,
value1, ...]} array) is replaced by:

  - {@code PeerTagSchema peerTagSchema}  -- nullable; schema for the values
  - {@code String[] peerTagValues}       -- parallel to schema.names

The producer captures only values; the consumer constructs the encoded
{@code "name:value"} UTF8 forms via {@code schema.handler(i).register(value)}
on its own thread.

Consumer-side cleanups bundled in
---------------------------------

While here, also addresses the perf review items raised against the prior
commit:

  - {@code hashOf}'s peer-tag loop is now indexed iteration; no more
    iterator allocation per snapshot.
  - {@code Canonical} now owns a reusable {@code peerTagsBuffer} ArrayList
    that's cleared+refilled per {@code populate} call -- zero allocation
    on the hit path. The buffer is copied into an immutable list only on
    miss when the entry needs to own it long-term.
  - {@code Canonical.matches} uses indexed list comparison; no iterator
    alloc in {@code List.equals}.
  - The {@code HashMap<String, TagCardinalityHandler> PEER_TAG_HANDLERS}
    on {@code AggregateEntry} is gone, replaced by the {@link
    PeerTagSchema}'s parallel array layout.

Benchmark (2 forks x 5 iter x 15s)
----------------------------------

  SimpleSpan bench:   3.165 +- 0.032 us/op   (prior: 3.117 +- 0.026)
  DDSpan bench:       2.727 +- 0.018 us/op   (prior: 2.344 +- 0.114)

Some producer-side regression from the per-snapshot schema sync (volatile
read + identity check). The fast-path identity comparison keeps it small;
hoisting the sync out of the per-snapshot loop is possible but would change
behavior in the edge case where {@code features.peerTags()} returns
different Sets within a single trace (covered by an existing test). Choosing
correctness over the marginal speedup.

Tests
-----

AggregateTableTest's snapshot builder is updated to construct a schema +
values via {@code PeerTagSchema.currentSyncedTo}, exercising the same code
path as production. Existing peer-tag test in {@code
ConflatingMetricAggregatorTest} still passes unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "Conflating" in the name dates from the prior design that used a Batch
pool + pending map to conflate up to 64 hits per inbox slot. That mechanism
is gone -- the producer now publishes one SpanSnapshot per span and the
consumer's AggregateTable is the conflation point. The new name matches the
existing protocol/metric terminology (HealthMetrics.onClientStat*,
stats.flush_payloads, etc.).

File renames:

  ConflatingMetricsAggregator.java         -> ClientStatsAggregator.java
  ConflatingMetricAggregatorTest.groovy    -> ClientStatsAggregatorTest.groovy
  ConflatingMetricsAggregatorBenchmark     -> ClientStatsAggregatorBenchmark
  ConflatingMetricsAggregatorDDSpan*       -> ClientStatsAggregatorDDSpan*

Plus all symbol references in MetricsAggregatorFactory and the test fixtures
that referenced the old class name.

No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small follow-ups carried over from a /techdebt pass:

- TracerHealthMetrics: previousCounts array was sized 51, but the prior
  commits added a 52nd reporter (statsInboxFull). Without this fix the new
  counter's report() call would throw ArrayIndexOutOfBoundsException; the
  Flush task swallows that exception, so the failure would be silent
  (statsInboxFull would just never make it to statsd).

- Aggregator: removes the now-dead public clearAggregates() method. The
  ClearSignal route from ClientStatsAggregator.disable() supplanted it
  several commits ago; the method had no remaining callers.

- TagCardinalityHandler: removes the unused register(TagMap.Entry) overload
  and its isValidType helper. The String-keyed overload covers all current
  callers (AggregateEntry's peer-tag canonicalization).

- PeerTagSchema: spotless-driven javadoc reflow only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ClientStatsAggregator.publish was calling features.peerTags() +
PeerTagSchema.currentSyncedTo for every span. Peer-tag configuration is
stable for the duration of a single trace publish in production --
DDAgentFeaturesDiscovery returns the same Set instance until remote-config
reconfiguration -- so the per-snapshot sync is wasted work.

Move the sync to once per publish(trace) and pass the resolved schema to
the inner publish(span, isTopLevel, peerAggSchema). INTERNAL-kind spans
still use the static PeerTagSchema.INTERNAL regardless.

Behavior boundary
-----------------

Schema changes from features.peerTags() now take effect at the next
publish(trace) call rather than mid-trace. Production-equivalent (a trace
takes microseconds to milliseconds; remote-config refreshes are seconds
apart), but a Spock test that used `>>> [...]` to mock different peerTags()
returns on successive calls within one trace no longer makes sense in the
new model. That test is rewritten to assert the production-relevant case:
peer-tag NAMES are stable, peer-tag VALUES vary per span, distinct value
combinations produce distinct aggregate buckets.

Benchmark (2 forks x 5 iter x 15s)
----------------------------------

  SimpleSpan bench:   3.133 +- 0.057 us/op   (prior: 3.165 +- 0.032)
  DDSpan bench:       2.454 +- 0.082 us/op   (prior: 2.727 +- 0.018)

Recovers ~270 ns/op on the DDSpan bench -- most of the regression introduced
by the per-snapshot lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JFR profiling showed ~21% of producer CPU time spent in tag-map lookups
during ClientStatsAggregator.publish. One of those lookups -- span.kind --
is redundant because DDSpanContext already caches the kind as a byte
ordinal that resolves to a String via a small array.

- Add CoreSpan.getSpanKindString() with a default that falls back to the
  tag map for non-DDSpan impls; DDSpan overrides to delegate to the
  context's cached resolution.
- Hoist schema.names array out of the capturePeerTagValues loop.
- Avoid an unnecessary toString() in isSynthetic by declaring
  SYNTHETICS_ORIGIN as String and using contentEquals.

Benchmark (ClientStatsAggregatorDDSpanBenchmark):
  before: 2.410 us/op
  after:  1.995 us/op  (~17% improvement)
  vs. master baseline (6.428 us/op): now ~3.2x faster.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the producer/consumer split, the canonical-key trick that makes
cardinality-blocking actually save space, the once-per-trace peer-tag
schema sync, the role of each file in datadog.trace.common.metrics, and
the rationale behind the redesign from ConflatingMetricsAggregator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@dougqh dougqh force-pushed the dougqh/control-tag-cardinality branch from 8020ec4 to 1221b2b Compare May 18, 2026 19:25
dougqh and others added 7 commits May 18, 2026 15:40
LongHashingUtilsTest (14 cases):
  - hashCodeX null sentinel + non-null pass-through
  - all primitive hash() overloads match the boxed Java hashCodes
  - hash(Object...) 2/3/4/5-arg overloads match the chained addToHash
    formula they are documented to constant-fold to
  - addToHash(long, primitive) overloads match the Object-version
  - linear-accumulation invariant (31 * h + v) holds across a sequence
  - iterable / deprecated int[] / deprecated Object[] variants match
    chained addToHash
  - intHash treats null as 0 (observable via hash(null, "x"))

HashtableTest (24 cases across 5 nested classes):
  - D1: insert/get/remove/insertOrReplace/clear/forEach, in-place value
    mutation, null-key handling, hash-collision chaining with disambig-
    uating equals, remove-from-collided-chain leaves siblings intact
  - D2: pair-key identity, remove(pair), insertOrReplace matches on
    both parts, forEach
  - Support: capacity rounds up to a power of two, bucketIndex stays
    in range across a wide hash sample, clear nulls every slot
  - BucketIterator: walks only matching-hash entries in a chain, throws
    NoSuchElementException when exhausted
  - MutatingBucketIterator: remove from head-of-chain unlinks, replace
    swaps the entry while preserving chain, remove() without prior
    next() throws IllegalStateException

Tests live in internal-api/src/test/java/datadog/trace/util and use the
already-present JUnit 5 setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bring the new util/ files in line with google-java-format
(tabs → spaces, line wrapping, javadoc list markup) so
spotlessCheck passes in CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Compares Hashtable.D1 and Hashtable.D2 against equivalent HashMap
usage for add, update, and iterate operations. Each benchmark thread
owns its own map (Scope.Thread), but @threads(8) is used so the
allocation/GC pressure that Hashtable is designed to avoid surfaces
in the throughput numbers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Guard Support.sizeFor against overflow and use Integer.highestOneBit;
  reject capacities above 1 << 30 instead of looping forever.
- Add braces around single-statement while bodies in BucketIterator.
- Split HashtableBenchmark into HashtableD1Benchmark / HashtableD2Benchmark.
- Add regression tests for Support.sizeFor bounds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 5-arg Object overload was forwarding only obj0..obj3 to the int
overload, silently dropping obj4. Also align LongHashingUtils.hash 3-arg
signature with its 2/4/5-arg siblings (int parameters) and strengthen
the 5-arg HashingUtilsTest to detect the missing-arg regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Split D1Tests and D2Tests into HashtableD1Test and HashtableD2Test;
  extract shared test entry classes into HashtableTestEntries.
- Reduce visibility of LongHashingUtils.hash(int...) chaining overloads
  to package-private; they are internal building blocks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dougqh and others added 5 commits June 22, 2026 11:50
…array

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
peerTagSchemaFor now takes the already-resolved spanKind String and calls
SpanKindFilter.matches(String) directly, avoiding two tag-map lookups that
getSpanKindString() had already paid for.

Canonical.peerTagsBuffer initialized to EMPTY_TAGS instead of null,
removing the null check in populate()'s hashOf() call and the null branch
in populatePeerTags().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ig to follow-up PR

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…mponent config lands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…metrics

Per RFC §2, the health metric tag dimension should use 'collapsed:<field>'
(e.g. 'collapsed:resource') rather than 'tag:<field>', and whole-key overflow
should emit 'collapsed:whole_key' rather than only the existing
stats.dropped_aggregates counter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@bric3 bric3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incomplete review. Just a few findings


// Sentinel substitution is disabled until per-component config is wired in a follow-up PR.
// Tests that need sentinel mode should pass useBlockedSentinel=true explicitly.
static final boolean LIMITS_ENABLED = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Do you have the follow-up PR ref ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up is #11732 (cardinality-limits-config) — it wires per-component limits through Config and flips LIMITS_ENABLED to the config-driven value.

dougqh and others added 2 commits June 25, 2026 11:45
…tricsIntegrationTest

Static per-field handlers on AggregateEntry accumulate state across tests.
AggregateTableTest and ClientStatsAggregatorTest already reset them; these
two were missing the guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The factory was test-only but lived in the production class. Making the
constructor package-private lets AggregateTableTest (same package) call it
directly; the test-only method is removed from the production API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dougqh

dougqh commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Claude (re: amarziali toString comment): Good call on the diagnostics value. The cardinality config fields were deferred out of this PR to keep the scope focused on the handler machinery. They will land in #11732, which wires per-component config; toString entries will be added there alongside the new fields.

@dougqh

dougqh commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Claude: Two follow-up notes on open threads:

DDAgentFeaturesDiscovery (orgPropagationMarker): The revert is confirmed pushed — field, parser, and getter are all removed from the branch as of the current push.

@beforeeach resetCardinalityHandlers (sarahchen6 thread): The prior reply was inaccurate — ClientStatsAggregatorTest was done but MetricsIntegrationTest was missed. Fixed today in 1689e54; AggregateEntryTest also got the same guard in the same commit.

dougqh and others added 3 commits June 25, 2026 14:54
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-private constructor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…alls

onStatsAggregateDropped() now emits an immediate collapsed_spans count
(RFC §2) in addition to the existing periodic stats.dropped_aggregates
report. Update the test to use CountDownLatch(2) and verify both calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@bric3 bric3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second batch of comments. I didn't reviewed yet PropertyCardinalityHandler, TagCardinalityHandler so there are coming next.

Globally I think the javadoc are kinda hard to read. I would really improve this as they are really useful to understand ow that works.

Comment thread dd-trace-core/src/main/java/datadog/trace/common/metrics/AggregateEntry.java Outdated
Comment thread dd-trace-core/src/main/java/datadog/trace/common/metrics/AggregateEntry.java Outdated
Comment thread dd-trace-core/src/main/java/datadog/trace/common/metrics/AggregateEntry.java Outdated
Comment thread dd-trace-core/src/main/java/datadog/trace/common/metrics/AggregateEntry.java Outdated
Comment on lines +557 to +561
&& grpcStatusCode.equals(e.grpcStatusCode)
&& peerTagsEqual(peerTagsBuffer, peerTagsSize, e.peerTags)
&& httpStatusCode == e.httpStatusCode
&& synthetic == e.synthetic
&& traceRoot == e.traceRoot;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Does it make sense to run peerTagsEqual before the int and boolean equality checks ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current ordering (UTF8 fields first, then peer tags, then primitives) is cardinality-tuned for short-circuiting: resource / service / operationName vary most across real collisions and filter most quickly, while peer tags require an O(n) list comparison. Moving peer tags before the primitives would add the list walk on collisions that only differ in httpStatusCode or synthetic — the common "same endpoint, different HTTP status" case. The ordering is documented in the matches() Javadoc.

@bric3 bric3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I went over everything. I insisted a bit on inline comments on cardinality handler register method, because they hold an interesting part of this change.

The claude generated javadoc is really hard to read. Maybe because I'm a non native english. But I believe the suggestions I gave significantly improves the review experience, after all this is not a small PR.

Also I wonder if some tsts are missing?

Comment on lines +6 to +44
/**
* Cardinality-capped UTF8 canonicalizer for one property field.
*
* <p><b>Dual role -- limiter and cache.</b> Prior versions ran a per-field {@code DDCache} for UTF8
* reuse with a separate global cardinality cap on top. Under high load that wasn't enough to stave
* off long GC cycles: every miss still concatenated / UTF8-encoded the value before the cache could
* store it. A cardinality limiter and a recent-value cache are both <em>sets of recently used
* values</em>, so this class collapses them into one structure. Cardinality limiting happens first,
* which lets the blocked path skip the concatenation and encoding entirely.
*
* <p>A pure limiter would fully reset each reporting cycle and destroy the cache. To preserve UTF8
* reuse across resets, the handler keeps the previous cycle's entries verbatim in a parallel table
* and reuses any matching {@link UTF8BytesString} when a value first appears in the new cycle.
*
* <p>Accepts any {@link CharSequence} input -- mixed {@code String}/{@code UTF8BytesString} of the
* same content collapse to one slot because {@link UTF8BytesString#hashCode()} delegates to the
* underlying String's hash and probe equality is the content-based {@code
* stored.toString().contentEquals(value)} (which fast-paths to {@code String.equals} when the input
* is a String).
*
* <p><b>Storage:</b> open-addressed flat arrays with linear probing. Two parallel {@code
* UTF8BytesString[]} tables -- "current cycle" and "prior cycle". Capacity is the next power of two
* {@code >= 2 * cardinalityLimit} so probes stay short even at the full budget. The stored
* UTF8BytesString carries the slot's identity directly; no parallel keys array needed.
*
* <ul>
* <li>The current table tracks which values have consumed a slot of the cardinality budget this
* reporting cycle. Once {@link #cardinalityLimit} distinct values are present, further
* first-time values get the {@code tracer_blocked_value} sentinel.
* <li>The prior table holds the previous cycle's entries verbatim. A first-time-this-cycle value
* that hits in the prior table reuses its {@link UTF8BytesString} instance -- no
* re-allocation -- and stores that reference in the current table.
* </ul>
*
* <p><b>Reset:</b> swap the current and prior pointers, then null the (now) current. One
* O(capacity) pass; half the work of a copy-then-null. Workloads with a stable value set across
* cycles pay zero UTF8 allocations after the first cycle, and the reused instances also
* short-circuit downstream equality to identity comparisons.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: What about this doc, it's mostly reformulation but it seems easier to read. I added a point on what measn a "cycle" in this context.

Suggested change
/**
* Cardinality-capped UTF8 canonicalizer for one property field.
*
* <p><b>Dual role -- limiter and cache.</b> Prior versions ran a per-field {@code DDCache} for UTF8
* reuse with a separate global cardinality cap on top. Under high load that wasn't enough to stave
* off long GC cycles: every miss still concatenated / UTF8-encoded the value before the cache could
* store it. A cardinality limiter and a recent-value cache are both <em>sets of recently used
* values</em>, so this class collapses them into one structure. Cardinality limiting happens first,
* which lets the blocked path skip the concatenation and encoding entirely.
*
* <p>A pure limiter would fully reset each reporting cycle and destroy the cache. To preserve UTF8
* reuse across resets, the handler keeps the previous cycle's entries verbatim in a parallel table
* and reuses any matching {@link UTF8BytesString} when a value first appears in the new cycle.
*
* <p>Accepts any {@link CharSequence} input -- mixed {@code String}/{@code UTF8BytesString} of the
* same content collapse to one slot because {@link UTF8BytesString#hashCode()} delegates to the
* underlying String's hash and probe equality is the content-based {@code
* stored.toString().contentEquals(value)} (which fast-paths to {@code String.equals} when the input
* is a String).
*
* <p><b>Storage:</b> open-addressed flat arrays with linear probing. Two parallel {@code
* UTF8BytesString[]} tables -- "current cycle" and "prior cycle". Capacity is the next power of two
* {@code >= 2 * cardinalityLimit} so probes stay short even at the full budget. The stored
* UTF8BytesString carries the slot's identity directly; no parallel keys array needed.
*
* <ul>
* <li>The current table tracks which values have consumed a slot of the cardinality budget this
* reporting cycle. Once {@link #cardinalityLimit} distinct values are present, further
* first-time values get the {@code tracer_blocked_value} sentinel.
* <li>The prior table holds the previous cycle's entries verbatim. A first-time-this-cycle value
* that hits in the prior table reuses its {@link UTF8BytesString} instance -- no
* re-allocation -- and stores that reference in the current table.
* </ul>
*
* <p><b>Reset:</b> swap the current and prior pointers, then null the (now) current. One
* O(capacity) pass; half the work of a copy-then-null. Workloads with a stable value set across
* cycles pay zero UTF8 allocations after the first cycle, and the reused instances also
* short-circuit downstream equality to identity comparisons.
*/
/**
* Cardinality-capped UTF8 encoder and cache for one field in the aggregate key (@{code value} &rarr; @{code UTF8(value)}).
*
* <p>A reporting cycle is the interval between two client-stats flushes. Values seen in the same
* cycle share one cardinality budget. When the aggregator flushes client stats, this handler is
* reset for the next cycle.
*
* <p>This handler has two jobs:
*
* <ul>
* <li>limit the number of distinct values accepted during one reporting cycle;
* <li>reuse {@link UTF8BytesString} instances for values seen recently.
* </ul>
*
* <p>The limit is checked before a new UTF8 value is created. When sentinel mode is enabled and the
* field has already reached {@link #cardinalityLimit} distinct values in the current cycle, a new
* value is represented as {@code tracer_blocked_value}. That avoids allocating and encoding a value
* that will be collapsed anyway.
*
* <p>The handler keeps two open-addressed tables:
*
* <ul>
* <li>the current-cycle table, which tracks values that have consumed this cycle's budget;
* <li>the prior-cycle table, which keeps the previous cycle's UTF8 values available for reuse.
* </ul>
*
* <p>At reset, the two tables are swapped and the new current-cycle table is cleared. This keeps
* UTF8 reuse across reporting cycles without copying entries. Stable workloads allocate UTF8 values
* during the first cycle, then reuse them in later cycles.
*
* <p>The tables use linear probing and a capacity of the next power of two greater than or equal to
* {@code 2 * cardinalityLimit}, so probe chains stay short at the full budget. The stored
* {@link UTF8BytesString} is also the table key: {@code String} and {@code UTF8BytesString} inputs
* with the same content map to the same slot because their hashes and equality checks are
* content-based.
*/

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in commit 17e96ddbc9.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only 6 lines were changed, the rest is still difficulty readable. I think claude just doesn't care.

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class AggregateTableTest {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: I wonder if it's worth adding a test there when the per tag budget is exceeded ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That test is blocked on AggregateEntry.LIMITS_ENABLED = false -- with sentinels disabled, values never collapse, so a per-tag budget exhausted test would just verify that unique values land in separate entries (which the existing differentKindFieldsAreDistinct / backToBackEvictionsAllSucceed tests already cover). I will add a budget-exceeded integration test in #11732 when the flag flips.

// skip publishing all children
forceKeep = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Also, it is interesting to see that no tests were failing, maybe something is missing. Or I misunderstood the code ?

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class AggregateEntryTest {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Maybe add a new test for the helath metrics: e.g. resetCardinalityHandlersReportsBlockedAggregateFieldValues, the idea is to verify the interaction when calling AggregateEntry.resetCardinalityHandlers(healthMetrics)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is blocked on AggregateEntry.LIMITS_ENABLED = false -- FIELD_HANDLERS are all constructed with useBlockedSentinel = false, so handler.reset() always returns 0 and onTagCardinalityBlocked is never invoked. The interaction test will be added in #11732 when the flag flips and the static handlers are live.

@bric3 bric3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I went over everything. I insisted a bit on inline comments on cardinality handler register method, because they hold an interesting part of this change.

The claude generated javadoc is really hard to read. Maybe because I'm a non native english. But I believe the suggestions I gave significantly improves the review experience, after all this is not a small PR.

Also I wonder if some tests are missing?

dougqh and others added 2 commits June 29, 2026 09:06
- DDAgentFeaturesDiscoveryTest: remove two tests referencing removed
  getOrgPropagationMarker() — fixes CI compilation failure
- ClientStatsAggregator: swap null-before-isEmpty check order per review
- PeerTagSchema/AggregateTable/AggregateEntry/MetricCardinalityLimits:
  Javadoc rewrites per reviewer feedback — remove change-history
  orientation, clarify thread-safety prose, drop stale MetricKey
  references, rename populate() to populateFrom()
- AggregateEntryTestUtils: remove "On this branch" phrasing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- AggregateTable: use Hashtable.Support.xxx() qualified form, fix stale
  ConflatingMetricsAggregator reference in Javadoc
- PropertyCardinalityHandler: add @VisibleForTesting, extract
  MAX_CARDINALITY_LIMIT constant, restructure inline comments in
  register() per review suggestions, add cycle definition to class doc

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dougqh

dougqh commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

I think I went over everything. I insisted a bit on inline comments on cardinality handler register method, because they hold an interesting part of this change.

The claude generated javadoc is really hard to read. Maybe because I'm a non native english. But I believe the suggestions I gave significantly improves the review experience, after all this is not a small PR.

Also I wonder if some tests are missing?

Yeah, I'm trying to find a balance. I might just have to go back and rewrite all the Javadoc myself.

…TagCardinalityHandler

Applied all bric3 review suggestions for TagCardinalityHandler:
- Replace class Javadoc with self-contained description of the tag-value encoding path
- Extract MAX_CARDINALITY_LIMIT = 1 << 29 constant (mirrors PropertyCardinalityHandler)
- Add @VisibleForTesting on 2-arg test constructor (uses datadog.trace.api.internal)
- Rewrite register() Javadoc to match PropertyCardinalityHandler's style
- Add inline comments throughout register() matching the parallel structure in
  PropertyCardinalityHandler (probe slot, current-cycle lookup, sentinel mode, prior reuse)
- Rename curKey -> existing to match PropertyCardinalityHandler naming

Also applied remaining PropertyCardinalityHandler suggestion:
- Add "This value is new for the current cycle." comment before capExhausted (MjO4h)

Test additions:
- tagRegisterOfNullDoesNotConsumeBudget (mirror of property equivalent)
- propertyResetReturnsBlockedCount + tagResetReturnsBlockedCount (verify reset() return value)
- PeerTagSchemaTest.resetHandlersReportsBlockedCountToHealthMetrics (verifies
  onTagCardinalityBlocked is called for any tag that hit its limit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@bric3 bric3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this looks better. Pre-approving, but there are still a few things to tweak before.

FYI the graalvm failing jobs are infrastructure related, restarting them should work.

Caused by: org.gradle.tooling.BuildException: Could not execute build using connection to Gradle distribution '.../artifact/services.gradle.org/distributions/gradle-8.14.5-bin.zip'.

In general the claude javdoc creates some cognitive overload, as I'm reading them and looking at code to compare both. But reading them is tough, at least for me. Personnally, I would prefer you write the doc or PR description ☺️.

Suggestion to put in the user file (CLAUDE.md or AGENTS.md):

* Keep Javadoc, comments, PR description writing informative, short, concise, legible, and blog-like.

* shared handler state. {@link UTF8BytesString}s are created directly. Content-equal entries from
* {@link Canonical#createEntry} still {@link #equals} an entry built via {@code of(...)}.
*/
static AggregateEntry of(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo: Did cluade forgot the suggested VisibleforTesting annotation (datadog.trace.api.internal.VisibleForTesting)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I'll see what I can do about both of those issues.

Comment on lines +6 to +44
/**
* Cardinality-capped UTF8 canonicalizer for one property field.
*
* <p><b>Dual role -- limiter and cache.</b> Prior versions ran a per-field {@code DDCache} for UTF8
* reuse with a separate global cardinality cap on top. Under high load that wasn't enough to stave
* off long GC cycles: every miss still concatenated / UTF8-encoded the value before the cache could
* store it. A cardinality limiter and a recent-value cache are both <em>sets of recently used
* values</em>, so this class collapses them into one structure. Cardinality limiting happens first,
* which lets the blocked path skip the concatenation and encoding entirely.
*
* <p>A pure limiter would fully reset each reporting cycle and destroy the cache. To preserve UTF8
* reuse across resets, the handler keeps the previous cycle's entries verbatim in a parallel table
* and reuses any matching {@link UTF8BytesString} when a value first appears in the new cycle.
*
* <p>Accepts any {@link CharSequence} input -- mixed {@code String}/{@code UTF8BytesString} of the
* same content collapse to one slot because {@link UTF8BytesString#hashCode()} delegates to the
* underlying String's hash and probe equality is the content-based {@code
* stored.toString().contentEquals(value)} (which fast-paths to {@code String.equals} when the input
* is a String).
*
* <p><b>Storage:</b> open-addressed flat arrays with linear probing. Two parallel {@code
* UTF8BytesString[]} tables -- "current cycle" and "prior cycle". Capacity is the next power of two
* {@code >= 2 * cardinalityLimit} so probes stay short even at the full budget. The stored
* UTF8BytesString carries the slot's identity directly; no parallel keys array needed.
*
* <ul>
* <li>The current table tracks which values have consumed a slot of the cardinality budget this
* reporting cycle. Once {@link #cardinalityLimit} distinct values are present, further
* first-time values get the {@code tracer_blocked_value} sentinel.
* <li>The prior table holds the previous cycle's entries verbatim. A first-time-this-cycle value
* that hits in the prior table reuses its {@link UTF8BytesString} instance -- no
* re-allocation -- and stores that reference in the current table.
* </ul>
*
* <p><b>Reset:</b> swap the current and prior pointers, then null the (now) current. One
* O(capacity) pass; half the work of a copy-then-null. Workloads with a stable value set across
* cycles pay zero UTF8 allocations after the first cycle, and the reused instances also
* short-circuit downstream equality to identity comparisons.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only 6 lines were changed, the rest is still difficulty readable. I think claude just doesn't care.

dougqh and others added 4 commits June 29, 2026 16:00
The factory was always test-only and the class Javadoc on AggregateEntryTestUtils
already stated the intent: "Lives in src/test so the production class stays free
of test-only API." Moving it there completes that design.

To enable the move:
- AggregateEntry constructor: private → package-private
- AggregateEntry.createUtf8(): private static → package-private static
Both are non-public internal helpers; widening to package-private exposes them
only within the same source set.

AggregateEntryTestUtils.of() now owns the implementation directly.
All callers updated: AggregateEntryTest (Java) and ClientStatsAggregatorTest (Groovy).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tyHandler

Remove DDCache history, storage layout details, and reset mechanics from
the class headers -- those belong in a design doc or the inline comments
that already explain them at the call site. Keep just what's non-obvious
from the class name: the cycle/budget concept and the prior-cycle reuse
invariant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…alityHandler

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
dougqh added a commit that referenced this pull request Jun 29, 2026
Skips the closed dougqh/metrics-memory-efficiency (#11389); #11387 now
serves as the direct base. Conflict resolution: took HEAD (combined
cardinality + additional-tags) for all conflicts. Restored @nullable
annotations on populatePeerTags/populateAdditionalTags params that were
lost during rebase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
dougqh added a commit that referenced this pull request Jun 29, 2026
…torTest

Both surfaced after the #11387#11402 reconcile merge: Config.java's static-import block
left TRACE_STATS_ADDITIONAL_TAGS out of alphabetical order (TRACER_* sorts before TRACE_*),
and a long SimpleSpan publish line in ClientStatsAggregatorTest needed wrapping. Pure
formatting; no behavior change. Restores green spotlessCheck on internal-api and dd-trace-core.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp: metrics Metrics tag: ai generated Largely based on code generated by an AI or LLM tag: performance Performance related changes type: enhancement Enhancements and improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants